Skip to content

fix: dispatch map lookups with normalized keys and nondeterministic null-guarded children - #5867

Open
dwsmith1983 wants to merge 40 commits into
apache:mainfrom
dwsmith1983:fix/serde-map-keys-and-nondeterministic-children
Open

dwsmith1983 wants to merge 40 commits into
apache:mainfrom
dwsmith1983:fix/serde-map-keys-and-nondeterministic-children

Conversation

@dwsmith1983

@dwsmith1983 dwsmith1983 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #5580, closes #5781.

Rationale for this change

Two serde gaps in the array and map expressions, both resolved by the codegen dispatcher rather than by native changes.

map_col[key] and element_at(map, key) decline float, collated and complex map keys because the native lookup compares raw Arrow values where Spark normalizes -0.0, treats NaN as equal to itself, compares strings by collation and compares complex keys with interpreted ordering. The declines are right, but neither serde mixed in CodegenDispatchFallback, so the whole projection fell back to Spark instead of running Spark's own generated code inside the Comet pipeline.

size, array_append, arrays_zip and map_from_arrays reproduce Spark's NULL propagation with a CASE WHEN child IS NOT NULL guard that serializes the child twice. A stateful child advances each copy independently, so the guard and the operation see different rows and the answer is silently wrong: on a 16-row table with IF(monotonically_increasing_id() % 2 = 0, array(1), NULL) as the operand, size returned -1 on five rows where Spark returns 1, arrays_zip returned [null, 2] for [1, 2], array_append returned [2] for [1, 2] and map_from_arrays returned NULL for {1 -> 2}. element_at had the same shape and was fixed in #5766 for its ANSI arm; these four are not ANSI-gated, so the wrong answers were reachable in every configuration.

What changes are included in this PR?

  • CometMapExtract and CometElementAt mix in CodegenDispatchFallback, so the declined key types run through the dispatcher. CometElementAt's ANSI arm for a nondeterministic operand dispatches the same way.
  • A shared NullGuardSupport gate declines any nondeterministic child in CometSize, CometArrayAppend (the array operand only; the item is not under the guard), CometArraysZip and CometMapFromArrays, and all four mix in CodegenDispatchFallback, so Spark's generated code evaluates the child once. Nullability is not consulted: a non-nullable stateful child only stays correct today because DataFusion skips the filter when the guard matches every row, which is not a contract to rely on.
  • getUnsupportedReasons lists updated for the doc generator, and the six affected rows in the expressions guide move from Native to Hybrid.

How are these changes tested?

SQL-file fixtures over parquet tables, all asserting Spark's answer and the execution path:

  • Four new *_nondeterministic_child.sql fixtures, one per serde, with the stateful operand (dispatched), a non-nullable stateful operand (dispatched), and a deterministic nullable operand (native). array_append also pins that a stateful item stays native, and its fixture is capped at Spark 3.5 because 4.x rewrites array_append to array_insert. Before the change, the stateful cases failed as result mismatches with the values above.
  • element_at_map.sql and get_map_value.sql flip their fallback cases to dispatch and add NaN lookups and a struct-keyed map column with a per-row key, a NULL inside the key and a NULL key. element_at_map_collation.sql flips the same way and passes on the Spark 4.0 profile. element_at_ansi.sql pins the dispatched nondeterministic arm.
  • map_from_arrays_dedup_policy.sql disables the dispatcher so its LAST_WIN fallback assertion keeps meaning what it says, matching the existing map_from_entries fixture.
  • CometMapExpressionSuite renames five fallback tests to dispatch tests through the helper that checks the dispatch tag.
  • array_append_ansi_null_array.sql runs with ANSI on and pins that an item raising on a NULL-array row raises through the dispatcher as in Spark, that a nullable array is dispatched, and that a non-nullable array stays native. The arrays_zip fixture adds a dispatched query over an array<int> column so the kernel copies list vectors from real input.

578 of 578 across CometSqlFileTestSuite, CometArrayExpressionSuite and CometMapExpressionSuite on Spark 3.5, the map fixtures on the Spark 4.0 profile, and test-compile on 4.0.

Performance

#5875 measured this same routing with CometCodegenDispatchBenchmark. At 65,536 rows, DOUBLE-key lookup-only projections ran 8.7 to 11.4 percent slower through the dispatcher than with it switched off, and mixed projections with a DOUBLE key and a small map ran 10.5 to 12.7 percent faster. The slower lookup-only case is accepted here in exchange for keeping the whole projection in Comet, where before it fell back to Spark.

What this does not cover

array_append keeps its item inside the native NULL guard's THEN branch, which DataFusion evaluates only on the rows where the array is not NULL, while Spark's codegen evaluates the item on every row. A nondeterministic item is now declined to the dispatcher for that reason. A deterministic item that raises under ANSI on a row whose array is NULL, such as 1 / (_1 - 1), would return cleanly on the native path where Spark raises. With ANSI on and a nullable array the serde now reports Incompatible, so array_append runs through the codegen dispatcher by default and raises like Spark, and the native path is available under allowIncompatible with that divergence recorded in the compatibility guide. Evaluating the item outside the guard, so the native path can serve ANSI too, is tracked in #6086.

@github-actions github-actions Bot added bug Something isn't working area:expressions Expression evaluation labels Sep 11, 2026
@dwsmith1983
dwsmith1983 force-pushed the fix/serde-map-keys-and-nondeterministic-children branch 2 times, most recently from ff812cc to 689c563 Compare September 12, 2026 01:48
…ull-guarded children

Map lookups with float, collated or complex keys were declined by the
serde without a dispatch fallback, so the whole projection fell back to
Spark. Four array and map serdes reproduce NULL propagation with a guard
that serializes the child twice, so a stateful child was evaluated twice
natively and returned wrong answers.

Mix the codegen dispatch fallback into the map lookups, and decline any
nondeterministic child in size, array_append, arrays_zip and
map_from_arrays through one shared gate so Spark's generated code
evaluates it once.

Closes apache#5580
Closes apache#5781

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above getSupportLevel says only the array operand sits under the NULL guard, but the item is inside the THEN branch too, and DataFusion's CaseExpr filters the batch before it evaluates THEN. So the item is evaluated only on the rows the guard selects.

That matters for array_append specifically, because it is the one expression in this group whose Spark codegen does not short-circuit. On 3.4 and 3.5 ArrayAppend.doGenCode emits leftGen.code + rightGen.code + ctx.nullSafeExec(left.nullable, leftGen.isNull) { ... }, so rightGen.code runs on every row even when the array is NULL. ElementAt and MapFromArrays go through nullSafeCodeGen and Size has one child, which is why the guard matches Spark for those three.

On your own 16 row table, SELECT _1, array_append(IF(_1 % 2 = 0, array(1), CAST(NULL AS ARRAY<INT>)), monotonically_increasing_id()) FROM test_array_append_nondet gives Spark [1,0], [1,2], [1,4] and so on for the even rows, because the counter advances on all 16. Comet evaluates the native monotonically_increasing_id only over the 8 filtered rows and gives [1,0], [1,1], [1,2] up to [1,7]. The same filtering also swallows an error the item would have raised. Under ANSI, array_append(IF(_1 % 2 = 0, array(1), CAST(NULL AS ARRAY<INT>)), 1 / (_1 - 1)) raises DIVIDE_BY_ZERO in Spark at _1 = 1 and returns cleanly in Comet.

The expect_native case below uses array(1), which is non-nullable, so the guard mask is all true and CaseExpr takes the branch that skips the filter entirely. That case passes whatever the serde does, which is why the fixture does not catch this. Would it make sense for NullGuardSupport to cover both children of ArrayAppend, and to change that fixture to a nullable operand with a stateful item so it fails without the change?

On sequencing, I have commented on #5875 pointing it here, since this PR carries the null-guard correctness work as well as the map lookup dispatch and that makes it the better base. Two things to settle before it lands though. #5875 measured this exact routing change and found lookup-only projections running 8.7 to 11.4 percent slower with the dispatcher on at 65536 rows, with no reproducible benefit at 1024 rows. Do you have numbers for the routing here? And #5854 teaches the native map builders LAST_WIN and null key rejection, which would remove the Incompatible branch this PR routes through the dispatcher for map_from_arrays, so that one needs an order agreed with @peterxcli.

Last thing is coverage for the routes this enables. map_from_arrays_dedup_policy.sql sets spark.comet.exec.scalaUDF.codegen.enabled=false and keeps asserting expect_fallback(mapKeyDedupPolicy), so the new LAST_WIN dispatch route is never exercised. Could those queries assert expect_dispatch(map_from_arrays) instead, with a separate dispatcher-off file if the fallback is still worth pinning? The four new nondeterministic fixtures also build every array inline over an int column, so no dispatched kernel in them reads an Arrow ListVector from a column. #5844 hit a real Spark 3.4.3 ColumnarArray.copy problem in exactly that shape, so an array<int> column in at least the size and map_from_arrays fixtures would be worth having. While you are in expressions.md, cardinality still says Native two rows above size, and Spark registers both names against Size.

@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Would it make sense for NullGuardSupport to cover both children of ArrayAppend, and to change that fixture to a nullable operand with a stateful item so it fails without the change?

Yes to both. CometArrayAppend now declines when either child is nondeterministic, and the fixture's native case is a nullable array with a stateful item, which fails without the change on exactly the 16-row table you used. The ANSI case where the item raises on a filtered row is the same mechanism but not nondeterminism; I have noted it in the description as the remaining gap for the guard shape rather than widening the decline to every non-literal item here.

Could those queries assert expect_dispatch(map_from_arrays) instead, with a separate dispatcher-off file if the fallback is still worth pinning?

Done: map_from_arrays_dedup_policy.sql asserts the dispatched route with the dispatcher on, and map_from_arrays_dedup_policy_dispatcher_off.sql pins the fallback.

an array<int> column in at least the size and map_from_arrays fixtures would be worth having

Added to both: the dispatched kernel now reads its arrays from columns as well as building them inline. cardinality says Hybrid.

Do you have numbers for the routing here?

Not yet; I will run the same shape #5875 measured before this lands. On #5854, once native LAST_WIN lands the map_from_arrays dispatch branch goes away and only the dispatcher-off fixture needs to follow, so I am fine with #5854 landing first.

@andygrove

Copy link
Copy Markdown
Member

Triage note: #5875 also closes #5580, and mixes CodegenDispatchFallback into the same two serdes (CometMapExtract and CometElementAt in maps.scala) with overlapping element_at / get_map_value fixtures. @LinSimon-901101 flagged the overlap in that description rather than letting us find it in review, and offered to defer.

From reading both, this one looks like the superset: it also closes #5781 for the stateful child being serialized twice under the CASE WHEN child IS NOT NULL guard in size, array_append, arrays_zip and map_from_arrays, which #5875 does not touch. Are you both happy for this PR to carry the map-lookup serde, with any non-overlapping coverage from #5875 rebased on top once it lands?

peterxcli added a commit to peterxcli/datafusion-comet that referenced this pull request Sep 18, 2026
…aches the null guards

The nested null guards serialize each child a second time inside the
`map_from_arrays` call, so a stateful child advances independently in
each copy: the guard's copy sees every row while the constructor's copy
sees only the rows the guard selected. With

    map_from_arrays(IF(monotonically_increasing_id() % 2 != 0, array(1), NULL), array(2))

over sixteen rows in one partition, Spark returns eight maps and Comet
returned four (apache#5781). Under LAST_WIN this case used to fall back for
the policy alone, so running the policy natively exposed it there.

Port `NullGuardSupport` from apache#5867 unchanged in name, reason and
position, so that PR rebases by dropping the hunk, and decline a
nondeterministic child in `CometMapFromArrays.getSupportLevel` as
`Unsupported`; the projection falls back to Spark, which evaluates the
child once. apache#5867 still routes the same decline through the JVM codegen
dispatcher and applies it to `size`, `array_append` and `arrays_zip`.

Cover it with the query above as a Scala test on a one-partition table
under LAST_WIN, the same query in `map_from_arrays_dedup_policy.sql`,
and `map_from_arrays_nondeterministic_child.sql` for the default policy,
which mirrors the fixture in apache#5867 with `expect_fallback` in place of
`expect_dispatch`.
@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Are you both happy for this PR to carry the map-lookup serde, with any non-overlapping coverage from #5875 rebased on top once it lands?

Yes, happy for this PR to carry the map-lookup serde. The size, array_append, arrays_zip and map_from_arrays fix has no counterpart in #5875, so rebasing its extra fixtures and benchmark cases on top once this lands is the smaller move. One sequencing note: if #5854 lands first, its native map_from_arrays replaces the LAST_WIN decline this PR wraps, and the gate here becomes the fallback chain peterxcli described on that PR.

@andygrove andygrove added this to the 1.1.0 milestone Sep 18, 2026
@andygrove andygrove added the run-all-spark-profiles Run the Comet test suites against every Spark profile on this pull request, ahead of the merge queue label Sep 21, 2026

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. I reran this locally and it is in good shape. 646 of 646 on Spark 4.1 and 637 of 637 on 3.4, the 9 canceled ones being the collation fixtures. I also neutralized NullGuardSupport.nondeterministicChild so it never declines, rebuilt, and reran the new fixtures. The three that are reachable on 4.1 fail with "Results do not match for query" rather than only a routing assertion, so they have real teeth. The array_append native case is no longer vacuous either.

I have applied run-all-spark-profiles. Two reasons. CI had never actually run here, every Comet CI run on the branch was sitting at action_required. And the pull request tier only runs Spark 4.1, so array_append_nondeterministic_child.sql could not run pre-merge at all with its MaxSparkVersion: 3.5 cap.

One more overlap to settle before this lands. #5844 also mixes CodegenDispatchFallback into CometMapFromArrays and rewrites map_from_arrays_dedup_policy.sql the same way, so it duplicates this pull request as well as #5875 and #5854. Are you and @LinSimon-901101 happy for this one to carry that too? Worth pulling one thing across either way. #5844 found that on Spark 3.4.3 the LAST_WIN fixture needs spark.sql.parquet.enableNestedColumnVectorizedReader=false because ColumnarArray.copy drops primitive-array NULLs (SPARK-48019), and this pull request now feeds array columns through the dispatcher in the same shape. My 3.4 run is green so I think your data avoids it, but it is worth knowing about.

On the benchmark, I do not think you need to re-measure. #5875 measured this exact routing and found DOUBLE-key lookup-only projections 8.7 to 11.4 percent slower at 65,536 rows, with mixed projections 10.5 to 12.7 percent faster. Could you cite those numbers here and say plainly that the lookup-only regression is accepted in exchange for keeping the projection in Comet? Right now that tradeoff is only recorded on the other pull request.

// The item sits inside the guard's THEN branch, and DataFusion's CaseExpr evaluates that
// branch only on the rows the guard selects, while Spark's codegen evaluates the item on
// every row. A stateful item therefore drifts the same way a stateful array does.
override def getSupportLevel(expr: ArrayAppend): SupportLevel =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I filed #6086 for the ANSI gap in your "What this does not cover" section, and confirmed it on main at 5ca1499. With spark.sql.ansi.enabled=true, array_append(IF(_1 % 2 = 0, array(1), CAST(NULL AS ARRAY<INT>)), 1 / (_1 - 1)) raises DIVIDE_BY_ZERO in Spark and returns cleanly in Comet. A control query in the same fixture confirms Comet's ANSI divide does raise on its own, so the test is not vacuous.

Could you link that issue from this comment? getSupportLevel reports Compatible() for the case, so without it nothing in the code or in the generated compatibility guide records the divergence.

While I was there I checked whether CometMapFromArrays has the mirror-image problem, since its guard is IsNotNull(left) AND IsNotNull(right) while Spark's nullSafeCodeGen short-circuits the values array. It does not reproduce. That one runs natively and matches Spark. The negative result is written up in the issue so nobody re-derives it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you link that issue from this comment?

Linked in 0d61cac, and the support level changed with it. With ANSI on and a nullable array, getSupportLevel now returns Incompatible with the #6086 note, so array_append runs through the codegen dispatcher by default and raises where Spark raises. The native guarded kernel only runs there under allowIncompatible, and the generated guide records the divergence as the opt-in consequence. With ANSI off, or a non-nullable array, nothing changes. A new array_append_ansi_null_array.sql fixture pins the dispatched raise and the native path for a non-nullable array. #6086 stays open for evaluating the item outside the guard, which would bring the native path back under ANSI.

*/
private[serde] object NullGuardSupport {

val nondeterministicReason: String =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This renders in the generated compatibility guide right next to MapKeySupport's reasons, which are full sentences about what Spark does and what the native path does instead. This one is a lowercase fragment about how the serde is built, which reads oddly as a user-facing bullet. Would something like "Comet has no native path for a nondeterministic operand such as rand() or monotonically_increasing_id(), because the native NULL guard would evaluate it twice" sit better there?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would something like "Comet has no native path for a nondeterministic operand such as rand() or monotonically_increasing_id(), because the native NULL guard would evaluate it twice" sit better there?

Yes. That is the reason now, word for word apart from the code formatting, and since the string is shared it changes the bullet for all four serdes in 0d61cac.


override def getUnsupportedReasons(): Seq[String] = Seq(
"Not all input data types are supported; falls back to Spark for unsupported types")
"Not all input data types are supported; unsupported types run through the JVM codegen " +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GenerateDocs already prints "The following cases have no native implementation and always run in the JVM using Spark's code-generated implementation (inside the Comet pipeline)" above these bullets whenever the serde mixes in CodegenDispatchFallback. So "unsupported types run through the JVM codegen dispatcher" now repeats its own header, and the bullet still never says which types. Could it name them instead, something like map, interval and variant element types?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could it name them instead, something like map, interval and variant element types?

Done in 0d61cac. The bullet now names what isTypeSupported declines: map, calendar, day-time and year-month interval, variant, TIME and user-defined element types, and a struct or inner array holding one of those. The dispatcher sentence is gone since the generated header already says it.

SELECT _1, arrays_zip(array(2), IF(monotonically_increasing_id() % 2 = 0, array(1), CAST(NULL AS ARRAY<INT>))) AS z
FROM test_arrays_zip_nondet

-- A deterministic nullable child stays on the native guarded path.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"A deterministic nullable child stays on the native guarded path" is sitting above the expect_dispatch query for a non-nullable stateful child, and the expect_native query below it has no comment at all. Looks like the two got swapped.

Separately, size and map_from_arrays picked up column-fed dispatch cases in this round but arrays_zip did not, and it is the one whose dispatched kernel copies ListVectors into an array<struct<>>. Would you mind adding one query here over an array<int> column?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the two got swapped.

They were. The nullable-child comment now sits above the expect_native query. The fixture also gained an arr array<int> column, NULL on every fourth row, and a dispatched arrays_zip query that reads both arrays from it, so the kernel copies list vectors from the input rather than building them inline. Flipping that query to expect_native fails the suite, so the sentinel is load-bearing. Both in 0d61cac.

…e arrays_zip types

With ANSI on, an item that raises on a row whose array is NULL raises in Spark
but not on the native path, because the NULL guard skips the item there. The
serde now reports that case as incompatible when the array is nullable, so it
runs through the JVM codegen dispatcher by default and the native guard stays
behind allowIncompatible. The divergence is recorded in the compatibility
guide with its tracking issue, and a fixture pins the raise, the dispatch and
the native path for a non-nullable array.

The shared nondeterministic reason is now a full sentence for the generated
guide, the arrays_zip type bullet names the element types the native kernel
declines, and the arrays_zip fixture gets its comments in the right order plus
a dispatched query over an array column.
@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Are you and @LinSimon-901101 happy for this one to carry that too?

Yes. This pull request already routes map_from_arrays through the dispatcher and rewrites map_from_arrays_dedup_policy.sql the same way, so #5844's remaining pieces can rebase on top like #5875's. On Spark 3.4.3 the LAST_WIN fixture here reads its arrays from parquet with the nested vectorized reader left at its default, and the 3.4 run you did came back green, so the data avoids SPARK-48019.

Could you cite those numbers here and say plainly that the lookup-only regression is accepted in exchange for keeping the projection in Comet?

Added to the description under a Performance heading, with the 65,536-row figures from #5875 and the tradeoff stated as you put it.

The four inline points are addressed in 0d61cac, with a reply on each thread. The SQL file suite passes on Spark 3.5 with the new column-fed arrays_zip query, and the regenerated compatibility guide shows the reworded reasons and the new array_append note.

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working through the last round. Everything I raised is addressed, and reporting the ANSI nullable-array case as Incompatible is a better answer than only linking #6086, because it keeps Compatible() honest.

One thing in the map fixtures before this merges. CAST(-0.0 AS DOUBLE) casts a decimal literal, and a decimal has no signed zero, so it produces +0.0. The lookups at lines 64, 67 and 76 of element_at_map.sql and line 50 of get_map_value.sql therefore look up +0.0, and the comments above them describe a -0.0 lookup that never happens. Could those use -0.0D instead, and CAST(-0.0D AS FLOAT) for the float case, so the fixtures exercise the normalization they describe?

An unsuffixed -0.0 is a decimal literal with no signed zero, so CAST(-0.0 AS DOUBLE)
looked up +0.0 and found the key on the native path too. The lookups now use -0.0D and
CAST(-0.0D AS FLOAT), which keep the sign, so the fixtures fail on results when the
float key decline is missing. The contributor guide names the literal form as well.
@dwsmith1983

Copy link
Copy Markdown
Contributor Author

Could those use -0.0D instead, and CAST(-0.0D AS FLOAT) for the float case, so the fixtures exercise the normalization they describe?

Done. The four lookups now use -0.0D, with CAST(-0.0D AS FLOAT) for the float key and array(-0.0D) for the array key, and the comments say why the suffix matters. typeof(-0.0) is decimal(1,1) and CAST(-0.0 AS DOUBLE) renders as 0.0, while -0.0D renders as -0.0.

The old form was hiding the answer check as well as the comment. With the float decline in MapKeySupport switched off, the old queries still returned 7 on the native path and only the dispatch assertion caught them. The new queries return null natively against Spark's 7 for both element_at and m[...], so the fixtures now fail on results when the guard is missing. Spark itself returns 7 for both forms, so the expected answers are unchanged. The other map fixtures and the aggregate fixtures already use -0.0D, and the signed zero tip in the contributor guide now names that form too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:expressions Expression evaluation bug Something isn't working run-all-spark-profiles Run the Comet test suites against every Spark profile on this pull request, ahead of the merge queue

Projects

None yet

2 participants